Skip to content

docs(readme): behavior accuracy, dry-run note, config vs registry locking#5

Merged
bschellenberger2600 merged 23 commits intomainfrom
docs/readme-dry-run-durability-locks
Apr 17, 2026
Merged

docs(readme): behavior accuracy, dry-run note, config vs registry locking#5
bschellenberger2600 merged 23 commits intomainfrom
docs/readme-dry-run-durability-locks

Conversation

@bschellenberger2600
Copy link
Copy Markdown
Member

@bschellenberger2600 bschellenberger2600 commented Apr 17, 2026

Summary

  • README: behavior aligned with code (full hydration, prune, backup refs, config example), dry-run pun, config vs registry durability, strict default_mode note.
  • Legacy cleanup: example config.toml and TUI Default mode row now use canonical global.default_mode strings (sync-default, sync-all, sync-current-branch, leave-untouched) matching git.ParseMode. Removed mistaken push-* labels that never matched the parser.
  • Validation: global.default_mode is validated on load; empty normalizes to sync-default; invalid values error.
  • Trimmed product copy that only mirrored git-fire (--rain flag, README); shortened CLAUDE.md maintenance notes.

Testing

  • make test-race
  • make lint

Note

Medium Risk
Touches concurrency/cancellation (scanner + TUI streaming) and config file write semantics via new file locking/atomic replace, which can affect runtime behavior and persistence if edge cases are missed, but changes are localized and test-covered.

Overview
Updates docs (README.md, CLAUDE.md, CLI long help) to clarify the mode hierarchy, when full branch hydration is triggered (beyond --sync), --prune precedence, dry-run behavior, backup-ref naming, and registry vs config locking semantics.

Hardens persistence and cancellation: config.SaveConfig/WriteExampleConfig now use a bounded cross-process lock (.lock) plus PID-scoped atomic replace, and global.default_mode is normalized/validated to the canonical values (rejecting legacy push-*). The --rain streaming TUI path cancels scans earlier, increases channel buffering, and makes repo scanning git subprocesses context-cancellable.

Refactors the Bubble Tea UI layout to prevent border gaps/cropping and width mis-measurements (new panel_layout.go, measured list capacity, lipgloss-based path width, ctrl-c handling), and adds focused tests for dry-run prune messaging, concurrent config writes, pre-cancelled scans, and layout invariants.

Reviewed by Cursor Bugbot for commit 2be0552. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Documentation

    • Clarified mode hierarchy and broadened risky/full-branch hydration scope; documented prune precedence, dry-run scanning behavior, new config fields/validation, registry/user config locking, TUI keybindings, and backup-ref naming.
  • Bug Fixes

    • Serialized concurrent config writes with locking and atomic replace; improved scan cancellation/shutdown and subprocess termination; tightened CLI help consistency.
  • New Features

    • Added/renamed config options (sync-default, fetch_workers, sync_tags, mainline_patterns) and UI layout/resizing improvements.
  • Tests

    • Added tests for dry-run prune messaging, concurrent config writes, pre-cancelled scans, and panel/layout behavior.

cursoragent and others added 9 commits April 16, 2026 16:45
Replace the previous default mainline-only remote fetch with git fetch
--all --prune per repository (honoring sync_tags). Introduce
--mainline-fetch to opt into the old MainlineFetchRemotes behavior.
Remove the redundant --fetch-only flag and update docs, UAT, and tests.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
README and CLAUDE describe default full fetch, --fetch-mainline,
--sync with branch_mode (including all-branches), and --risky on sync.
Rename CLI flag from --mainline-fetch to --fetch-mainline and fix
variable naming; update UAT labels and test messages.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Default fetch is git fetch --all without --prune. Add global fetch_prune,
registry fetch_prune, local rain.fetchprune, and --prune/--no-prune flags
(CLI wins). Wire RainOptions.FetchPrune through fetchOnly, MainlineFetchRemotes,
and RainRepository. Document precedence and pruning risks in README with a
warning callout. Add unit tests for resolution order and ReadRainFetchPrune.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Dry-run now prints resolved Fetch --prune for --sync as well as fetch paths.
Registry Upsert for-loop indentation matches other methods after gofmt.
…guard

- applyRepoFetchPrune: skip git config read when CLI sets prune; warn and
  fall back on ReadRainFetchPrune errors
- Reject --fetch-mainline with full-sync triggers; add test
- runDryRun: show fetch --prune resolution for --sync path too
- Fix Upsert for-loop indentation (gofmt)
- UAT scenario 7 echo matches prune-opt-in default
- CLAUDE/README: prune precedence and --fetch-mainline note
- Replace misleading frozen label (fetch --all)

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
- Resolve conflicts in cmd/root.go and cmd/root_test.go (keep fetch-mainline
  note and dry-run prune test).
- SaveConfig and WriteExampleConfig: exclusive flock on config.toml.lock,
  PID-scoped temp file, atomic rename (avoids corruption / stale .tmp).
- Add github.com/gofrs/flock; concurrent SaveConfig test.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Match full-branch-hydration triggers to computeFullSync, fix default_mode
example, document prune precedence and backup ref shape. Add a dry-run
pun and a durability note contrasting repos.toml locking with config.toml
atomic writes and remaining race scenarios.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ab9d4c4-1dbc-40cd-8793-e222b2b32325

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Docs and TUI behavior clarified and renamed (push→sync); config defaults/validation changed to sync-default; config/registry writes gain PID-scoped temp files with file locking; scanner and git subprocesses become context-aware/cancellable; TUI layout, buffering, and shutdown ordering refactored; tests added across CLI, config, scanner, and UI.

Changes

Cohort / File(s) Summary
Documentation
README.md, CLAUDE.md
Rewrote operational-mode ordering; expanded --risky semantics to full branch-hydration paths; clarified --prune precedence, --dry-run scanning, TUI keybindings, and backup-ref naming; updated config field names/semantics.
CLI runtime & TUI stream
cmd/root.go
Help text tweaks; increased tuiRepoChan buffer to 256; changed shutdown/cancellation order to call cancelScan() immediately and wait for bridge completion.
CLI tests
cmd/root_test.go
Added TestRunRain_DryRunSyncShowsPruneResolution asserting dry-run prune-resolution banner and "Would hydrate" messaging.
Config loader & defaults
internal/config/loader.go, internal/config/defaults.go, internal/config/config_test.go
Writes now use exclusive .lock via flock, PID-scoped temp files and atomic rename; Validate() trims and enforces allowed default_mode values (leave-untouched, sync-default, sync-all, sync-current-branch); added concurrent-write and validation tests.
Go modules
go.mod
Added indirect dependency github.com/gofrs/flock v0.12.1.
Registry types doc
internal/registry/types.go
Updated inline doc for RegistryEntry.Mode allowed values to sync-oriented set.
Git scanner (context & cancellation)
internal/git/scanner.go, internal/git/scanner_test.go
Propagated context into analysis and subprocesses (exec.CommandContext); analyzer returns early on cancelled ctx; added test ensuring pre-cancelled ScanRepositoriesStream drains output channel.
UI panel/layout helpers & tests
internal/ui/panel_layout.go, internal/ui/panel_layout_test.go
Added panel sizing and normalization helpers plus tests to ensure consistent box/padding/width rendering and border alignment.
Path width & repo selector refactor
internal/ui/path_display.go, internal/ui/repo_selector.go, internal/ui/view_layout.go, internal/ui/view_layout_test.go, internal/ui/panel_layout.go
Renamed PathWidthFor param to terminalWidth; switched to panel-based cell-width measurements; refactored repo selector/view to use Panel* helpers, measured-list capacity, consistent clamping/truncation, added interrupt handling mapping to ErrCancelled, and updated config UI enum options (push→sync).
Config UI
internal/ui/config_view.go
Updated default-mode enum to sync-* variants; standardized content width usage, clamping via clampCellWidth, and switched to renderMainPanelBox for final panel rendering.

Sequence Diagram(s)

sequenceDiagram
  participant User as "User (TUI)"
  participant TUI as "TUI (picker)"
  participant Scanner as "Scanner (filesystem/git)"
  participant Channel as "tuiRepoChan (buffered)"
  participant Bridge as "bridge goroutine / drain"
  participant CancelCtx as "Scan cancel context"

  Note over TUI,Scanner: Streaming scan start
  User->>TUI: open picker
  TUI->>Scanner: Start ScanRepositoriesStream(ctx)
  Scanner-->>Channel: send repo items (async)
  Channel-->>TUI: deliver items (render)
  alt User selects repo or exits
    TUI->>CancelCtx: cancelScan() (immediately)
    CancelCtx-->>Scanner: context cancelled
    Scanner--xChannel: stop sending / close channel
    TUI->>Bridge: wait for bridgeDone while draining Channel
    TUI->>Channel: drain remaining items
    Bridge-->>TUI: bridgeDone
    TUI->>TUI: shutdown UI and return
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through README fields tonight,

tightened locks and set defaults right.
Temp files safe, scans that heed a call,
channels drained before the final fall.
A tidy rabbit hums — the UI stands tall.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main documentation changes: README updates focused on behavior accuracy, dry-run semantics, and locking mechanisms for both config and registry files.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

- Example config and TUI config screen use sync-default/sync-all/
  sync-current-branch/leave-untouched (matches ParseMode).
- Validate global.default_mode strictly; empty normalizes to sync-default.
- Trim git-fire mirroring from CLI flag text and README; tighten CLAUDE.md.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
README.md (1)

244-244: ⚠️ Potential issue | 🟡 Minor

Resolve the --branch-mode trigger contradiction.

Line 244 says --branch-mode applies “With --sync”, but Line 28 and Line 305 say any CLI --branch-mode triggers full branch hydration even without --sync. These statements conflict and will confuse users.

Suggested doc fix
-| `--branch-mode` | With `--sync`: `mainline`, `checked-out`, `all-local`, or `all-branches` (overrides config for this run) |
+| `--branch-mode` | Branch selection mode: `mainline`, `checked-out`, `all-local`, or `all-branches`. On CLI, providing this flag enters the full branch-hydration path for the run (overrides config). |

Based on learnings: Use RainRepository for local branch hydration when --sync, non-mainline branch_mode, or risky-only config forces full sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 244, Update the README to remove the contradictory wording
that `--branch-mode` only applies "With `--sync`" and clearly document the
actual behavior: state that supplying the CLI flag `--branch-mode` triggers full
branch hydration even when `--sync` is not provided, and additionally explain
that when `--sync` is used the system uses `RainRepository` for local branch
hydration and that non-mainline `branch_mode` or a risky-only config also forces
full sync; reference the flags `--branch-mode` and `--sync`, the config key
`branch_mode`, and the class `RainRepository` so readers can map the behavior to
the implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Line 240: Update the README entry for `--dry-run` to clarify its behavior when
combined with `--no-scan`: mention that `--dry-run` normally skips git
fetch/branch updates but still scans the disk to list repos, and explicitly add
a caveat that if `--no-scan` is also provided, the disk scan is skipped and
`--dry-run` will not list repos from disk; reference the `--dry-run` and
`--no-scan` flags so readers understand the interaction.

---

Outside diff comments:
In `@README.md`:
- Line 244: Update the README to remove the contradictory wording that
`--branch-mode` only applies "With `--sync`" and clearly document the actual
behavior: state that supplying the CLI flag `--branch-mode` triggers full branch
hydration even when `--sync` is not provided, and additionally explain that when
`--sync` is used the system uses `RainRepository` for local branch hydration and
that non-mainline `branch_mode` or a risky-only config also forces full sync;
reference the flags `--branch-mode` and `--sync`, the config key `branch_mode`,
and the class `RainRepository` so readers can map the behavior to the
implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a59ebd6-1ad1-459e-8fa1-fce7c79d1219

📥 Commits

Reviewing files that changed from the base of the PR and between 95f0929 and 80df3cb.

📒 Files selected for processing (1)
  • README.md

Comment thread README.md Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/config/loader.go (1)

118-125: ⚠️ Potential issue | 🟡 Minor

Cleanup stale tmp on os.WriteFile failure too.

If os.WriteFile fails mid-write (e.g. ENOSPC, permissions), a partial path.<pid>.tmp can be left behind — cleanup only happens on Rename failure. Mirror the same os.Remove(tmp) on the WriteFile error path for symmetry and to avoid littered temp files across runs.

Proposed fix
 	tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
 	if err := os.WriteFile(tmp, data, 0o600); err != nil {
+		_ = os.Remove(tmp)
 		return fmt.Errorf("writing temp config: %w", err)
 	}

Apply the same to WriteExampleConfig at line 228.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/loader.go` around lines 118 - 125, The temp file created as
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) is not removed if
os.WriteFile(tmp, data, 0o600) fails, leaving stale tmp files; update the error
path in the block that writes and renames the temp file to call os.Remove(tmp)
before returning when os.WriteFile fails, and apply the same cleanup change to
the analogous WriteExampleConfig function so both write-failure and
rename-failure paths remove the temporary file (reference tmp, path,
os.WriteFile, os.Rename, and WriteExampleConfig to locate the spots to change).
🧹 Nitpick comments (1)
internal/config/loader.go (1)

215-235: Duplication with SaveConfig — consider extracting.

WriteExampleConfig is now structurally identical to SaveConfig (lock → write tmp → rename), differing only in payload source and error-message wording. Worth extracting a small atomicWrite(path string, data []byte) error helper to keep the lock/tmp/rename invariants in one place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/loader.go` around lines 215 - 235, WriteExampleConfig
duplicates SaveConfig's lock/tmp/rename logic; extract that shared behavior into
a new helper atomicWrite(path string, data []byte) error that acquires the same
file lock (flock.New(path+".lock")), writes to a temp file
(fmt.Sprintf("%s.%d.tmp", path, os.Getpid())), renames it into place, and cleans
up on error; then update SaveConfig and WriteExampleConfig to call atomicWrite
with their respective payloads (e.g., ExampleConfigTOML() output) and preserve
existing error wrapping and permissions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/config/loader.go`:
- Around line 108-112: Replace the blocking flock.Lock() call in SaveConfig with
a non-blocking TryLockContext: create a context with a short timeout (e.g.,
500ms) and call lock.TryLockContext(ctx, retryDelay) instead of lock.Lock(); on
timeout or error propagate the error through the existing configSaveErr
mechanism so the TUI can surface the failure; ensure you still defer unlocking
(lock.Unlock()) when TryLockContext succeeds and keep using flock.New(path +
".lock") as before.

---

Outside diff comments:
In `@internal/config/loader.go`:
- Around line 118-125: The temp file created as tmp := fmt.Sprintf("%s.%d.tmp",
path, os.Getpid()) is not removed if os.WriteFile(tmp, data, 0o600) fails,
leaving stale tmp files; update the error path in the block that writes and
renames the temp file to call os.Remove(tmp) before returning when os.WriteFile
fails, and apply the same cleanup change to the analogous WriteExampleConfig
function so both write-failure and rename-failure paths remove the temporary
file (reference tmp, path, os.WriteFile, os.Rename, and WriteExampleConfig to
locate the spots to change).

---

Nitpick comments:
In `@internal/config/loader.go`:
- Around line 215-235: WriteExampleConfig duplicates SaveConfig's
lock/tmp/rename logic; extract that shared behavior into a new helper
atomicWrite(path string, data []byte) error that acquires the same file lock
(flock.New(path+".lock")), writes to a temp file (fmt.Sprintf("%s.%d.tmp", path,
os.Getpid())), renames it into place, and cleans up on error; then update
SaveConfig and WriteExampleConfig to call atomicWrite with their respective
payloads (e.g., ExampleConfigTOML() output) and preserve existing error wrapping
and permissions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 95376c1a-20e6-4f86-b9f1-7add88bf9878

📥 Commits

Reviewing files that changed from the base of the PR and between 80df3cb and 7ce1ab6.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (10)
  • CLAUDE.md
  • README.md
  • cmd/root.go
  • cmd/root_test.go
  • go.mod
  • internal/config/config_test.go
  • internal/config/defaults.go
  • internal/config/loader.go
  • internal/registry/types.go
  • internal/ui/config_view.go
✅ Files skipped from review due to trivial changes (6)
  • go.mod
  • internal/registry/types.go
  • CLAUDE.md
  • cmd/root.go
  • internal/config/defaults.go
  • internal/config/config_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • README.md

Comment thread internal/config/loader.go
cursoragent and others added 3 commits April 17, 2026 05:26
Add PanelBlockWidth/PanelTextWidth/RainDisplayWidth so list, settings, rain,
and PathWidthFor share one layout budget (outer margin 6 + box padding 4).
Clamp list/settings rows with lipgloss; align lipgloss padding in tests.
Treat tea.InterruptMsg like quit; map tea.ErrInterrupted to ErrCancelled.

Co-authored-by: bschellenberger2600 <bschellenberger2600@gmail.com>
…lysis

Cancel scan context immediately after the TUI returns so filepath walk and
git subprocesses unwind. Buffer tuiRepoChan to reduce bridge backpressure.
Use exec.CommandContext in ScanRepositoriesStream analysis; add test that a
pre-cancelled scan still closes the output channel.

Co-authored-by: bschellenberger2600 <bschellenberger2600@gmail.com>
Document q/ctrl+c and scan cancellation in README; point maintainers at
panel_layout and runRainTUIStream shutdown in CLAUDE.md.

Co-authored-by: bschellenberger2600 <bschellenberger2600@gmail.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
cmd/root.go (1)

552-564: ⚠️ Potential issue | 🟡 Minor

Bridge goroutine's saveRegistry isn't awaited.

<-scanDone only waits for the scanner goroutine, not for the bridge goroutine at lines 526–536 that does saveRegistry(reg, regPath) after scanChan closes. On the ui.ErrCancelled path a few lines below, os.Exit(0) fires as soon as the drain returns, which can happen before the bridge finalizes and persists registry changes (e.g., newly-discovered repos, LastSeen bumps).

♻️ Suggested fix
 	var scanErr error
 	scanDone := make(chan struct{})
 	go func() {
 		defer close(scanDone)
 		scanErr = git.ScanRepositoriesStream(opts, scanChan)
 	}()

 	now := time.Now()
 	defaultMode := git.ParseMode(cfg.Global.DefaultMode)
+	bridgeDone := make(chan struct{})
 	go func() {
+		defer close(bridgeDone)
 		defer close(tuiRepoChan)
 		for repo := range scanChan {
 			repo, include := upsertRepoIntoRegistry(reg, repo, now, defaultMode)
 			if include {
 				repo.Selected = true
 				tuiRepoChan <- repo
 			}
 		}
 		saveRegistry(reg, regPath)
 	}()
 	...
 	<-scanDone
+	<-bridgeDone
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/root.go` around lines 552 - 564, The bridge goroutine that reads scanChan
and calls saveRegistry(reg, regPath) is not awaited: current code only waits on
<-scanDone (scanner) so the program can exit before the bridge finishes
persisting registry changes. Fix by adding an explicit synchronization for the
bridge (e.g., a done channel or sync.WaitGroup) that the bridge signals after
saveRegistry returns, then wait for that signal before returning/exiting (on the
ui.ErrCancelled path and the normal shutdown path); reference the bridge
goroutine that closes over scanChan and calls saveRegistry(reg, regPath), the
existing scanDone waiter, and the cancelScan() call to ensure the new wait
happens after cancelScan() and draining.
internal/ui/repo_selector.go (1)

1226-1231: ⚠️ Potential issue | 🔴 Critical

Remove unused min — CI lint is failing.

golangci-lint reports func min is unused. After switching to resolveRainBackgroundWidth / RainDisplayWidth, nothing calls the local helper anymore, and Go 1.21+ provides a built-in min anyway.

🧹 Proposed fix
-func min(a, b int) int {
-	if a < b {
-		return a
-	}
-	return b
-}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/repo_selector.go` around lines 1226 - 1231, Remove the unused
local helper function min in internal/ui/repo_selector.go: delete the min(a, b
int) int definition since golangci-lint flags it as unused and Go 1.21+ provides
a built-in min; also search for any residual callers (e.g., inside
resolveRainBackgroundWidth or RainDisplayWidth) and replace them with the
language built-in min if any remain, ensuring no references to the min function
are left behind.
🧹 Nitpick comments (2)
internal/ui/path_display.go (1)

84-89: Document what reserve = 34 accounts for.

The measured prefixW/suffixW exclude the dirty indicator ( 💧, ~3 cells) and the scroll hint ( << SCROLL PATH >>, ~19 cells) which only appears on the focused row. A single opaque 34 makes this easy to drift if either string is tweaked elsewhere. Consider either breaking it out or at least commenting the derivation.

♻️ Example
-	suffixW := lipgloss.Width(fmt.Sprintf("›)  [%s] %s", repo.Mode.String(), remotesInfo))
-	const reserve = 34
+	suffixW := lipgloss.Width(fmt.Sprintf("›)  [%s] %s", repo.Mode.String(), remotesInfo))
+	// reserve = dirty indicator (" 💧" ≈ 3) + scroll hint ("  << SCROLL PATH >>" ≈ 19) + safety margin.
+	const reserve = 34
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/path_display.go` around lines 84 - 89, The hard-coded reserve =
34 is undocumented and hides the width contributions from the per-row dirty
indicator and the focused-row scroll hint; update the pw calculation in
path_display.go to replace the magic number with a named constant (e.g.,
scrollHintWidth or reservedWidth) or add a clear inline comment explaining the
derivation (dirty indicator ~3 cells + scroll hint ~19 cells + any padding = 34)
and use that constant when computing pw alongside prefixW and suffixW so future
changes to the dirty indicator or scroll hint are obvious; reference the
existing variables prefixW, suffixW, reserve (or new constant) and pw to locate
where to apply the change.
internal/git/scanner.go (1)

163-179: Remove the errors.Is(err, context.Canceled) checks—they never match for killed subprocesses.

When exec.CommandContext cancels a child process, cmd.Output() returns an *exec.ExitError with signal information, not an error satisfying errors.Is(err, context.Canceled). The subsequent ctx.Err() guards already detect cancellation correctly, making these branches unreachable dead code.

♻️ Suggested simplification
 	remotes, err := getRemotes(ctx, repoPath)
 	if err == nil {
 		repo.Remotes = remotes
-	} else if errors.Is(err, context.Canceled) {
-		return Repository{}, err
 	}
 
 	if err := ctx.Err(); err != nil {
 		return Repository{}, err
 	}
 
 	dirty, err := isDirty(ctx, repoPath)
 	if err == nil {
 		repo.IsDirty = dirty
-	} else if errors.Is(err, context.Canceled) {
-		return Repository{}, err
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/git/scanner.go` around lines 163 - 179, The checks for
errors.Is(err, context.Canceled) after calling getRemotes and isDirty are
dead/unreliable for killed subprocesses; remove those branches and rely on the
existing ctx.Err() guard instead. Specifically, in internal/git/scanner.go
update the getRemotes and isDirty call sites (the variables remotes, err :=
getRemotes(ctx, repoPath) and dirty, err := isDirty(ctx, repoPath)) to drop the
else if errors.Is(err, context.Canceled) return Repository{}, err blocks and
simply handle success by assigning repo.Remotes / repo.IsDirty and otherwise let
the subsequent ctx.Err() check and the generic error return handle cancellation
and other errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@cmd/root.go`:
- Around line 552-564: The bridge goroutine that reads scanChan and calls
saveRegistry(reg, regPath) is not awaited: current code only waits on <-scanDone
(scanner) so the program can exit before the bridge finishes persisting registry
changes. Fix by adding an explicit synchronization for the bridge (e.g., a done
channel or sync.WaitGroup) that the bridge signals after saveRegistry returns,
then wait for that signal before returning/exiting (on the ui.ErrCancelled path
and the normal shutdown path); reference the bridge goroutine that closes over
scanChan and calls saveRegistry(reg, regPath), the existing scanDone waiter, and
the cancelScan() call to ensure the new wait happens after cancelScan() and
draining.

In `@internal/ui/repo_selector.go`:
- Around line 1226-1231: Remove the unused local helper function min in
internal/ui/repo_selector.go: delete the min(a, b int) int definition since
golangci-lint flags it as unused and Go 1.21+ provides a built-in min; also
search for any residual callers (e.g., inside resolveRainBackgroundWidth or
RainDisplayWidth) and replace them with the language built-in min if any remain,
ensuring no references to the min function are left behind.

---

Nitpick comments:
In `@internal/git/scanner.go`:
- Around line 163-179: The checks for errors.Is(err, context.Canceled) after
calling getRemotes and isDirty are dead/unreliable for killed subprocesses;
remove those branches and rely on the existing ctx.Err() guard instead.
Specifically, in internal/git/scanner.go update the getRemotes and isDirty call
sites (the variables remotes, err := getRemotes(ctx, repoPath) and dirty, err :=
isDirty(ctx, repoPath)) to drop the else if errors.Is(err, context.Canceled)
return Repository{}, err blocks and simply handle success by assigning
repo.Remotes / repo.IsDirty and otherwise let the subsequent ctx.Err() check and
the generic error return handle cancellation and other errors.

In `@internal/ui/path_display.go`:
- Around line 84-89: The hard-coded reserve = 34 is undocumented and hides the
width contributions from the per-row dirty indicator and the focused-row scroll
hint; update the pw calculation in path_display.go to replace the magic number
with a named constant (e.g., scrollHintWidth or reservedWidth) or add a clear
inline comment explaining the derivation (dirty indicator ~3 cells + scroll hint
~19 cells + any padding = 34) and use that constant when computing pw alongside
prefixW and suffixW so future changes to the dirty indicator or scroll hint are
obvious; reference the existing variables prefixW, suffixW, reserve (or new
constant) and pw to locate where to apply the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 139853fb-5261-473b-8d30-5b4d28edcc8d

📥 Commits

Reviewing files that changed from the base of the PR and between 7ce1ab6 and cf8e229.

📒 Files selected for processing (10)
  • CLAUDE.md
  • README.md
  • cmd/root.go
  • internal/git/scanner.go
  • internal/git/scanner_test.go
  • internal/ui/config_view.go
  • internal/ui/panel_layout.go
  • internal/ui/panel_layout_test.go
  • internal/ui/path_display.go
  • internal/ui/repo_selector.go
✅ Files skipped from review due to trivial changes (1)
  • CLAUDE.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/ui/config_view.go
  • README.md

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missing space between label and value in settings view
    • Added a literal space in the format string between the styled label and value so rows render as "label: value" again.
Preview (c116659eb1)
diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,10 +9,8 @@
 1. **Default** — `git fetch --all` per repo. `--prune` is opt-in. Precedence per repo: CLI (`--prune` / `--no-prune`) → local `git config rain.fetchprune` → registry `fetch_prune` → global `fetch_prune`.
 2. **`--fetch-mainline`** — targeted fetches for mainline branches only (incompatible with `--sync` / full-sync triggers; CLI returns an error if combined).
 3. **`--sync` + `branch_mode`** — hydrate local branches from remotes.
-4. **`--risky`** — on the sync path only; allows hard reset to upstream after backup refs.
+4. **`--risky`** — on the full branch-hydration path; allows hard reset to upstream after backup refs.
 
-Extracted from the `git-fire` codebase and promoted to a first-class tool.
-
 Module: `github.com/git-rain/git-rain`
 Go version: 1.24.2
 Config: `~/.config/git-rain/config.toml`
@@ -58,8 +56,8 @@
 **Key design decisions:**
 - Uses native `git` binary via `exec.Command` — not go-git.
 - Default run: `git fetch --all` with optional `--prune` (resolved per repo: CLI → `rain.fetchprune` → registry `fetch_prune` → `global.fetch_prune`) and optional `--tags`. Mainline-only: `internal/git.MainlineFetchRemotes` when `--fetch-mainline`. Local hydrate: `RainRepository` when `--sync`, non-mainline `branch_mode`, or risky-only config forces full sync.
-- Interactive picker: `--rain` (mirrors `git-fire --fire`).
-- Backup branch prefix: `git-rain-backup-` (was `git-fire-rain-backup-` in git-fire).
+- Interactive picker: `--rain` (streaming scan + Bubble Tea UI). Panel width math lives in `internal/ui/panel_layout.go` (`PanelBlockWidth` / `PanelTextWidth`); keep it aligned with `boxStyle` horizontal padding. On exit, `runRainTUIStream` cancels the scan context before draining channels so in-flight `git` from `ScanRepositoriesStream` can abort; OS SIGINT maps to the same cancel path as `q` via `tea.ErrInterrupted` → `ErrCancelled`.
+- Backup branch prefix: `git-rain-backup-`.
 - Config env prefix: `GIT_RAIN_`.
 - Safe mode (default): never rewrites local-only commits (applies to `--sync` path).
 - Risky mode (`--risky` / `config: global.risky_mode`): allows hard reset to upstream after creating a `git-rain-backup-*` ref (implies full sync).

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -18,10 +18,26 @@
 git-rain  →  fetch all remotes by default, or hydrate locals with --sync

-git-rain discovers git repositories under your scan path (and known registry entries). Default: git fetch --all per repo (no --prune unless you opt in) so remote-tracking refs update without moving local branches. Lighter fetch: --fetch-mainline. Local updates: --sync with --branch-mode / config. Destructive realignment: --risky on the sync path only.
+git-rain discovers git repositories under your scan path (and known registry entries). From lightest to heaviest:

-> Warning: --prune is opt-in. Passing --prune on git fetch deletes stale remote-tracking branch refs (for example refs/remotes/origin/old-feature after that branch was removed on the server). That is usually what you want for a tidy clone, but it removes those ref names locally until the next fetch brings them back if the branch reappears. Enable pruning only when you intend it, in this order of override: --prune / --no-prune for this run, per-repo git config --local --bool rain.fetchprune, fetch_prune on a registry entry, or global.fetch_prune in config. Effective precedence for each repo is: CLIrain.fetchpruneregistry fetch_pruneglobal fetch_prune.
+| Mode | What it does |
+| --- | --- |
+| Default | git fetch --all per repo. --prune is opt-in (see below). Updates remote-tracking refs; does not move local branches. |
+| Lighter fetch | --fetch-mainline — mainline remote-tracking refs only. |
+| Local updates | --sync — hydrate locals; scope from --branch-mode or config. |
+| Destructive realignment | --risky or config risky_mode on the full branch-hydration path (same machinery as --sync). That path also runs without --sync when you pass --risky, set risky_mode, use a non-mainline branch_mode in config, or pass any --branch-mode value on the CLI (even mainline). Hard-reset to upstream only after backup refs. |

+> Warning: --prune is opt-in. On git fetch, --prune deletes stale remote-tracking branch refs (for example refs/remotes/origin/old-feature after that branch was removed on the server). That is usually what you want for a tidy clone, but it removes those ref names locally until a later fetch brings them back if the branch reappears. Turn pruning on only when you mean to.
+
+Where --prune is decided (per repo, first applicable source wins):
+
+1. CLI--prune or --no-prune for this run
+2. Local git configgit config --local --bool rain.fetchprune
+3. Registryfetch_prune on the repo entry
+4. User configglobal.fetch_prune
+
+Precedence chain: CLIrain.fetchpruneregistry fetch_pruneglobal fetch_prune.
+
Invocation note: git-rain and git rain are equivalent when git-rain is on your PATH.

Table of Contents

@@ -51,7 +67,8 @@

Quick Start

-# preview first — shows what would be synced without touching anything
+# preview first — lists repos and whether each would get fetch-only vs branch hydration, without running git
+# (still does a filesystem scan; the flag name is a little ironic — "dry" rain that still kicks up dust)
git-rain --dry-run

# default: scan repos, then git fetch --all per repo (no --prune unless configured or --prune)
@@ -167,7 +184,7 @@

4. **`--sync`** — hydrates **local** branches: `git fetch --all` (same prune/tags rules), then updates eligible locals toward upstream. Scope comes from **`--branch-mode`** or config `branch_mode`: `mainline`, `checked-out`, `all-local`, or **`all-branches`** (creates local tracking branches for remotes you do not have yet — can be many branches).

-5. **`--risky`** — does not change fetch behavior by itself; on the **`--sync`** path it allows hard reset to upstream after creating `git-rain-backup-*` refs when you would otherwise skip local-only commits.
+5. **`--risky`** — does not change fetch behavior by itself; on the **full branch-hydration** path (see `--sync` above — entered by `--sync`, **`--risky`**, config **`risky_mode`**, a **non-mainline `branch_mode`**, or **any `--branch-mode` flag** on the CLI) it allows hard reset to upstream after creating `git-rain-backup-*` refs when you would otherwise skip local-only commits.

6. **Report** — one summary line per repo on the default full fetch; per-branch lines on `--fetch-mainline` and `--sync`.

@@ -177,7 +194,7 @@
- **Safety-first defaults** — never rewrites local-only commits; dirty worktrees are skipped, not clobbered
- **Risky mode** — opt-in destructive realignment: creates a `git-rain-backup-*` ref, then hard-resets to upstream
- **Non-checked-out branches** — updated directly without touching the worktree
-- **Interactive TUI (`--rain`)** — streaming repo picker (mirrors `git-fire --fire`), then the same default fetch, `--fetch-mainline`, or `--sync` behavior
+- **Interactive TUI (`--rain`)** — streaming repo picker, then the same default fetch, `--fetch-mainline`, or `--sync` behavior
- **Registry** — discovered repos persist across runs; mark repos ignored to skip them permanently
- **Dry run** — preview all repos that would be fetched or synced without making any changes
- **`--fetch-mainline`** — mainline-only remote-tracking ref refresh instead of the default full `git fetch --all`
@@ -220,8 +237,8 @@

| Flag | Description |
|---|---|
-| `--dry-run` | Show what would run without making changes |
-| `--rain` | Interactive TUI repo picker before running (like `git-fire --fire`) |
+| `--dry-run` | No `git fetch` / branch updates — still scans disk to list repos. The name is weather-themed irony: no “wet” git work, but not a no-op. |
+| `--rain` | Interactive TUI repo picker before running |
| `--sync` | Update local branches from remotes (after `git fetch --all`; default run does not sync locals) |
| `--fetch-mainline` | Mainline-only remote `git fetch` per remote instead of default `git fetch --all` (not with `--sync` or other full-sync triggers) |
| `--branch-mode` | With `--sync`: `mainline`, `checked-out`, `all-local`, or `all-branches` (overrides config for this run) |
@@ -251,13 +268,17 @@
```toml
[global]
scan_path    = "/home/you/projects"   # root to discover repos under
-scan_depth   = 5                      # max directory depth
-scan_workers = 8                      # parallel scan workers
-risky_mode   = false                  # destructive realignment on --sync path only
-branch_mode  = "mainline"             # used with --sync: mainline | checked-out | all-local | all-branches
-fetch_prune  = false                  # pass --prune on fetch when true (default off; see README warning)
-default_mode = "safe"                 # "safe" or "risky"
-disable_scan = false                  # skip scan; use registry only
+scan_depth   = 5                      # max directory depth (default in app: 10)
+scan_workers = 8                    # parallel scan workers
+fetch_workers = 4                   # parallel per-repo operations (default in app: 4)
+risky_mode   = false                # allow destructive realignment on full hydration path
+branch_mode  = "mainline"           # full hydration: mainline | checked-out | all-local | all-branches
+fetch_prune  = false                # pass --prune on fetch when true (default off; see README warning)
+sync_tags    = false                # pass --tags on fetch when true; CLI --tags still forces tags for the run
+# Registry default for new repos (TUI / opt-out): leave-untouched | sync-default | sync-all | sync-current-branch
+default_mode = "sync-default"
+disable_scan = false                # skip scan; use registry only
+mainline_patterns = []              # extra mainline names/prefixes when branch_mode = mainline

scan_exclude = [
  "node_modules",
@@ -266,6 +287,8 @@
]

+global.default_mode must be exactly one of the four values listed above; anything else fails config load.
+
All options can be overridden with environment variables using the GIT_RAIN_ prefix:

@@ -273,9 +296,15 @@
GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain

+### Config file, locks, and crashes
+
+Registry (repos.toml) — Writes use a cross-process lock file (repos.toml.lock), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use --force-unlock-registry in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other git-rain is running.
+
+User config (config.toml) — There is no cross-process lock on the config file today. The TUI saves settings with an atomic write (temp file then rename into place), so an ungraceful exit mid-save should not replace config.toml with a half-written file; you might leave an orphan config.toml.tmp, which is safe to delete if present. Avoid hand-editing config.toml at the same moment an interactive --rain session is saving, or two editors racing writes — a future shared lock could remove that race.
+

Interactive TUI

-git-rain --rain opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the default full fetch (git fetch --all, prune opt-in) unless you passed --fetch-mainline, or --sync / config implies full branch hydration.
+git-rain --rain opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the default full fetch (git fetch --all, prune opt-in) unless you passed --fetch-mainline, or full branch hydration is implied by --sync, --risky, risky_mode in config, a non-mainline branch_mode, or any --branch-mode on the CLI. Quitting (q or ctrl+c) cancels the in-progress scan (in-flight git subprocesses are aborted via the scan context); ctrl+c outside raw TTY mode is treated like cancel.

Key bindings:

@@ -284,7 +313,8 @@
| space | Toggle repo selection |
| a | Select all / deselect all |
| enter | Confirm selection and begin fetch or sync |
-| q / esc | Abort |
+| q / ctrl+c | Abort picker |
+| c / Esc | Back from settings (ignored list uses Esc / i / b) |
| / | Navigate |

Safe Mode vs Risky Mode

@@ -296,7 +326,7 @@
| Checked-out branch, dirty worktree | ⊘ Skipped | ⊘ Skipped |
| No upstream tracked | ⊘ Skipped | ⊘ Skipped |

-In risky mode, a git-rain-backup-<branch>-<timestamp> ref is created before any hard reset so local work is always recoverable.
+In risky mode, a backup ref named like git-rain-backup-<sanitized-branch>-<timestamp>-<short-sha> is created before any hard reset so local work is always recoverable.

Registry

diff --git a/cmd/root.go b/cmd/root.go
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -53,7 +53,8 @@
use --prune, config fetch_prune, registry fetch_prune, or git config rain.fetchprune.

  1. --fetch-mainline — targeted fetches for mainline branches only (faster when
  • you do not need every remote ref).
    
  • you do not need every remote ref). Cannot be combined with --sync, --risky,
    
  • non-mainline --branch-mode, or global risky_mode (full-sync triggers).
    
    1. --sync — hydrate local branches from remotes. Use --branch-mode for scope
      (mainline, checked-out, all-local, all-branches); all-branches can create
      @@ -91,7 +92,7 @@
      rootCmd.Flags().BoolVar(&rainFetchMainline, "fetch-mainline", false, "Fetch only mainline remote-tracking refs per remote (lighter than the default git fetch --all)")
      rootCmd.Flags().BoolVar(&rainInit, "init", false, "Generate example ~/.config/git-rain/config.toml")
      rootCmd.Flags().StringVar(&rainConfigFile, "config", "", "Use an explicit config file path")
  • rootCmd.Flags().BoolVar(&rainRain, "rain", false, "Interactive TUI repo picker before running (mirrors git-fire --fire)")
  • rootCmd.Flags().BoolVar(&rainRain, "rain", false, "Interactive TUI repo picker before running")
    rootCmd.Flags().BoolVar(&rainSync, "sync", false, "Update local branches from remotes (default is git fetch --all only)")
    rootCmd.Flags().StringVar(&rainBranchMode, "branch-mode", "", Branch sync mode: mainline (default), checked-out, all-local, all-branches)
    rootCmd.Flags().BoolVar(&rainSyncTags, "tags", false, "Fetch all tags from remotes (default: off)")
    @@ -510,7 +511,8 @@
    opts.FolderProgress = folderProgress

    scanChan := make(chan git.Repository, opts.Workers)

  • tuiRepoChan := make(chan git.Repository, opts.Workers)
  • // Large buffer so the bridge rarely blocks on send if the TUI stops consuming.

  • tuiRepoChan := make(chan git.Repository, 256)

    var scanErr error
    scanDone := make(chan struct{})
    @@ -547,7 +549,10 @@
    regPath,
    )

  • // Drain channels before cancelling so goroutines can't block on sends.
  • // Cancel scan first so filepath walk and in-flight git subprocesses unwind.
  • cancelScan()
  • // Drain the TUI channel so the bridge never blocks on send while the scanner closes.
    go func() {
    for range tuiRepoChan {
    }
    @@ -556,7 +561,6 @@
    for range folderProgress {
    }
    }()
  • cancelScan()
    <-scanDone

    if err != nil {

diff --git a/cmd/root_test.go b/cmd/root_test.go
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -102,6 +102,36 @@
}
}

+func TestRunRain_DryRunSyncShowsPruneResolution(t *testing.T) {

  • tmpHome := t.TempDir()
  • setTestUserDirs(t, tmpHome)
  • scenario := testutil.NewScenario(t)
  • repo := scenario.CreateRepo("dry-sync-prune").
  •   AddFile("a.txt", "x\n").
    
  •   Commit("init")
    
  • resetFlags()
  • rainPath = filepath.Dir(repo.Path())
  • rainDryRun = true
  • rainSync = true
  • rainPrune = true
  • var runErr error
  • output := captureStdout(t, func() {
  •   runErr = runRain(rootCmd, []string{})
    
  • })
  • if runErr != nil {
  •   t.Fatalf("runRain() dry-run sync error = %v", runErr)
    
  • }
  • if !strings.Contains(output, "Fetch --prune: on for this run (--prune)") {
  •   t.Fatalf("expected dry-run to show prune on for --sync path, got:\n%s", output)
    
  • }
  • if !strings.Contains(output, "Would hydrate") {
  •   t.Fatalf("expected dry-run hydrate wording for --sync, got:\n%s", output)
    
  • }
    +}

func TestRunRain_DefaultFetchAllDoesNotMoveLocalBranch(t *testing.T) {
tmpHome := t.TempDir()
setTestUserDirs(t, tmpHome)

diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -26,6 +26,7 @@
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect

  • github.com/gofrs/flock v0.12.1 // indirect
    github.com/inconshreveable/mousetrap v1.1.0 // indirect
    github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
    github.com/mattn/go-localereader v0.0.1 // indirect

diff --git a/go.sum b/go.sum
--- a/go.sum
+++ b/go.sum
@@ -33,6 +33,8 @@
github.com/git-fire/git-testkit v0.2.0/go.mod h1:YlJlkY9JfGdYTe9o9W3l+gv9BPj05FGu6HK36Z5jwVA=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
+github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -98,5 +100,6 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

diff --git a/internal/config/config_test.go b/internal/config/config_test.go
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -3,6 +3,7 @@
import (
"os"
"path/filepath"

  • "sync"
    "testing"

    "github.com/git-rain/git-rain/internal/config"
    @@ -109,6 +110,37 @@
    }
    }

+func TestSaveConfig_ConcurrentWrites(t *testing.T) {

  • dir := t.TempDir()
  • cfgPath := filepath.Join(dir, "config.toml")
  • var wg sync.WaitGroup
  • for i := 0; i < 8; i++ {
  •   wg.Add(1)
    
  •   go func(i int) {
    
  •   	defer wg.Done()
    
  •   	c := config.DefaultConfig()
    
  •   	if i%2 == 0 {
    
  •   		c.Global.BranchMode = "mainline"
    
  •   	} else {
    
  •   		c.Global.BranchMode = "all-local"
    
  •   	}
    
  •   	if err := config.SaveConfig(&c, cfgPath); err != nil {
    
  •   		t.Errorf("SaveConfig %d: %v", i, err)
    
  •   	}
    
  •   }(i)
    
  • }
  • wg.Wait()
  • loaded, err := config.LoadWithOptions(config.LoadOptions{ConfigFile: cfgPath})
  • if err != nil {
  •   t.Fatalf("Load after concurrent saves: %v", err)
    
  • }
  • if loaded.Global.BranchMode != "mainline" && loaded.Global.BranchMode != "all-local" {
  •   t.Fatalf("unexpected branch mode %q", loaded.Global.BranchMode)
    
  • }
    +}

func TestSaveConfig_RoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.toml")
@@ -148,11 +180,30 @@
}
}

+func TestValidate_DefaultMode_EmptyBecomesDefault(t *testing.T) {

  • cfg := config.DefaultConfig()
  • cfg.Global.DefaultMode = ""
  • if err := cfg.Validate(); err != nil {
  •   t.Fatalf("Validate() error = %v", err)
    
  • }
  • if cfg.Global.DefaultMode != "sync-default" {
  •   t.Fatalf("DefaultMode = %q, want sync-default", cfg.Global.DefaultMode)
    
  • }
    +}

+func TestValidate_DefaultMode_Invalid(t *testing.T) {

  • cfg := config.DefaultConfig()
  • cfg.Global.DefaultMode = "push-known-branches"
  • if err := cfg.Validate(); err == nil {
  •   t.Fatal("Validate() should reject invalid default_mode")
    
  • }
    +}

func TestExampleConfigTOML_ContainsBranchMode(t *testing.T) {
toml := config.ExampleConfigTOML()

  • for _, want := range []string{"branch_mode", "sync_tags", "fetch_prune", "mainline_patterns"} {
  • for _, want := range []string{"branch_mode", "sync_tags", "fetch_prune", "mainline_patterns", default_mode = "sync-default"} {
    if !contains(toml, want) {
  •   	t.Errorf("ExampleConfigTOML missing key %q", want)
    
  •   	t.Errorf("ExampleConfigTOML missing fragment %q", want)
      }
    
    }
    }

diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -77,7 +77,7 @@
fetch_workers = 4

Default mode for repos (used by registry opt-out model)

-default_mode = "push-known-branches"
+default_mode = "sync-default"

Re-scan known repos for new submodules

rescan_submodules = false

diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -6,6 +6,7 @@
"path/filepath"
"strings"

  • "github.com/gofrs/flock"
    "github.com/pelletier/go-toml/v2"
    "github.com/spf13/viper"
    )
    @@ -97,17 +98,24 @@
    }

// SaveConfig writes cfg to path as TOML. Intermediate directories are created
-// if needed. Existing file content is replaced atomically.
+// if needed. Uses an exclusive lock (path + ".lock") and a PID-scoped temp file
+// so concurrent writers or interrupted renames cannot corrupt the live config.
func SaveConfig(cfg *Config, path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}

  • lock := flock.New(path + ".lock")
  • if err := lock.Lock(); err != nil {
  •   return fmt.Errorf("config file lock: %w", err)
    
  • }
  • defer func() { _ = lock.Unlock() }()
  • data, err := toml.Marshal(cfg)
    if err != nil {
    return fmt.Errorf("marshalling config: %w", err)
    }
  • tmp := path + ".tmp"
  • tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
    if err := os.WriteFile(tmp, data, 0o600); err != nil {
    return fmt.Errorf("writing temp config: %w", err)
    }
    @@ -130,6 +138,16 @@

// Validate checks if the configuration is valid.
func (c *Config) Validate() error {

  • dm := strings.TrimSpace(c.Global.DefaultMode)
  • if dm == "" {
  •   dm = DefaultConfig().Global.DefaultMode
    
  • }
  • switch dm {
  • case "leave-untouched", "sync-default", "sync-all", "sync-current-branch":
  •   c.Global.DefaultMode = dm
    
  • default:
  •   return fmt.Errorf("global.default_mode must be one of leave-untouched, sync-default, sync-all, sync-current-branch, got %q", c.Global.DefaultMode)
    
  • }
    if c.Global.FetchWorkers <= 0 {
    c.Global.FetchWorkers = DefaultFetchWorkers
    }
    @@ -193,14 +211,26 @@
    }

// WriteExampleConfig writes an example config file to the specified path.
+// Same locking and atomic replace semantics as SaveConfig.
func WriteExampleConfig(path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}

  • lock := flock.New(path + ".lock")
  • if err := lock.Lock(); err != nil {
  •   return fmt.Errorf("config file lock: %w", err)
    
  • }
  • defer func() { _ = lock.Unlock() }()
  • content := ExampleConfigTOML()
  • if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
  •   return fmt.Errorf("failed to write config file: %w", err)
    
  • tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
  • if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
  •   return fmt.Errorf("failed to write temp config: %w", err)
    
    }
  • if err := os.Rename(tmp, path); err != nil {
  •   _ = os.Remove(tmp)
    
  •   return fmt.Errorf("failed to replace config file: %w", err)
    
  • }
    return nil
    }

diff --git a/internal/git/scanner.go b/internal/git/scanner.go
--- a/internal/git/scanner.go
+++ b/internal/git/scanner.go
@@ -2,6 +2,7 @@

import (
"context"

  • "errors"
    "fmt"
    "os"
    "os/exec"
    @@ -46,7 +47,7 @@
    if err != nil || !fi.IsDir() {
    return
    }
  •   	repo, err := analyzeRepository(p)
    
  •   	repo, err := analyzeRepository(ctx, p)
      	if err != nil {
      		return
      	}
    

@@ -146,31 +147,42 @@

// AnalyzeRepository extracts metadata from a git repository at repoPath.
func AnalyzeRepository(repoPath string) (Repository, error) {

  • return analyzeRepository(repoPath)
  • return analyzeRepository(context.Background(), repoPath)
    }

-func analyzeRepository(repoPath string) (Repository, error) {
+func analyzeRepository(ctx context.Context, repoPath string) (Repository, error) {

  • if err := ctx.Err(); err != nil {
  •   return Repository{}, err
    
  • }
    repo := Repository{
    Path: repoPath,
    Name: filepath.Base(repoPath),
    Selected: true,
    }
  • remotes, err := getRemotes(repoPath)
  • remotes, err := getRemotes(ctx, repoPath)
    if err == nil {
    repo.Remotes = remotes
  • } else if errors.Is(err, context.Canceled) {
  •   return Repository{}, err
    
    }
  • dirty, err := isDirty(repoPath)
  • if err := ctx.Err(); err != nil {

  •   return Repository{}, err
    
  • }

  • dirty, err := isDirty(ctx, repoPath)
    if err == nil {
    repo.IsDirty = dirty

  • } else if errors.Is(err, context.Canceled) {

  •   return Repository{}, err
    

    }

    return repo, nil
    }

-func getRemotes(repoPath string) ([]Remote, error) {

  • cmd := exec.Command("git", "remote", "-v")
    +func getRemotes(ctx context.Context, repoPath string) ([]Remote, error) {
  • cmd := exec.CommandContext(ctx, "git", "remote", "-v")
    cmd.Dir = repoPath

    output, err := cmd.Output()
    @@ -210,8 +222,8 @@
    return remotes, nil
    }

-func isDirty(repoPath string) (bool, error) {

  • cmd := exec.Command("git", "status", "--porcelain")
    +func isDirty(ctx context.Context, repoPath string) (bool, error) {
  • cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
    cmd.Dir = repoPath

    output, err := cmd.Output()

diff --git a/internal/git/scanner_test.go b/internal/git/scanner_test.go
new file mode 100644
--- /dev/null
+++ b/internal/git/scanner_test.go
@@ -1,0 +1,41 @@
+package git
+
+import (

  • "context"
  • "testing"
  • testutil "github.com/git-fire/git-testkit"
    +)

+// TestScanRepositoriesStream_PreCancelledDrain verifies that when the scan
+// context is already cancelled, ScanRepositoriesStream still closes the output
+// channel so callers never block on range. This guards the --rain quit path
+// where cancelScan runs while the picker exits.
+func TestScanRepositoriesStream_PreCancelledDrain(t *testing.T) {

  • scenario := testutil.NewScenario(t)
  • repo := scenario.CreateRepo("scan-pre-cancel").
  •   AddFile("README.md", "x\n").
    
  •   Commit("init")
    
  • ctx, cancel := context.WithCancel(context.Background())
  • cancel()
  • out := make(chan Repository, 8)
  • opts := ScanOptions{
  •   RootPath:       repo.Path(),
    
  •   Workers:        2,
    
  •   Ctx:            ctx,
    
  •   MaxDepth:       4,
    
  •   DisableScan:    true,
    
  •   KnownPaths:     map[string]bool{repo.Path(): false},
    
  •   FolderProgress: nil,
    
  • }
  • err := ScanRepositoriesStream(opts, out)
  • if err != nil {
  •   t.Fatalf("ScanRepositoriesStream: %v", err)
    
  • }
  • for range out {
  • }
    +}

diff --git a/internal/registry/types.go b/internal/registry/types.go
--- a/internal/registry/types.go
+++ b/internal/registry/types.go
@@ -21,7 +21,7 @@
// Status: "active", "missing", or "ignored"
Status string toml:"status"

  • // Last-used mode (e.g. "push-known-branches")
  • // Last-used per-repo disposition: leave-untouched | sync-default | sync-all | sync-current-branch
    Mode string toml:"mode,omitempty"

    // Per-repo override for submodule re-scanning.

diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go
--- a/internal/ui/config_view.go
+++ b/internal/ui/config_view.go
@@ -27,10 +27,10 @@

var configRows = []configRow{
{label: "Default mode", kind: configRowEnum, options: []string{

  •   "push-known-branches",
    
  •   "push-all",
    
  •   "sync-default",
    
  •   "sync-all",
    
  •   "sync-current-branch",
      "leave-untouched",
    
  •   "push-current-branch",
    
    }},
    {label: "Disable scan", kind: configRowBool},
    {label: "Fetch workers", kind: configRowEnum, options: []string{
    @@ -244,10 +244,10 @@

func (m RepoSelectorModel) viewConfig() string {
var s strings.Builder

  • cw := m.contentWidth()

    if m.rainVisible() {

  •   cw := m.contentWidth()
    
  •   rainW := min(cw, 70)
    
  •   rainW := RainDisplayWidth(m.windowWidth)
      s.WriteString(m.rainBg.Render())
      s.WriteString("\n")
      s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode))
    

@@ -259,7 +259,12 @@
Foreground(activeProfile().titleFg).
Background(activeProfile().titleBg).
Padding(0, 2)

  • s.WriteString(titleGradient.Render("🌧️ GIT RAIN — SETTINGS"))
  • title := "🌧️ GIT RAIN — SETTINGS"

  • if cw <= 0 {

  •   s.WriteString(titleGradient.Render(title))
    
  • } else {

  •   s.WriteString(titleGradient.MaxWidth(cw).Render(title))
    
  • }
    s.WriteString("\n\n")

    cursorStyle := lipgloss.NewStyle().Foreground(activeProfile().configCursor).Bold(true)
    @@ -283,29 +288,32 @@
    case configRowComingSoon:
    hintStr = dimStyle.Render(" coming soon")
    default:

  •   		hintStr = dimStyle.Render("  ←/→ to change")
    
  •   		if cw >= 88 {
    
  •   			hintStr = dimStyle.Render("  ←/→ to change")
    
  •   		} else if cw >= 64 {
    
  •   			hintStr = dimStyle.Render("  ←/→")
    
  •   		}
      	}
      }
    
  •   line := fmt.Sprintf("%s  %-32s %s%s",
    
  •   line := fmt.Sprintf("%s  %s %s%s",
      	cursorStyle.Render(cur),
      	labelStyle.Render(row.label+":"),
      	valueStyle.Render(val),
      	hintStr,
      )
    
  •   s.WriteString(line)
    
  •   s.WriteString(clampCellWidth(line, cw))
      s.WriteString("\n")
    

    }

    s.WriteString("\n")
    if m.configSaveErr != nil {
    errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6666"))

  •   s.WriteString(errStyle.Render("⚠️  Save failed: " + m.configSaveErr.Error()))
    
  •   s.WriteString(clampCellWidth(errStyle.Render("⚠️  Save failed: "+m.configSaveErr.Error()), cw))
      s.WriteString("\n")
    
  •   s.WriteString(helpStyle.Render(
    
  •   	"In-memory settings updated; fix the error above to persist to disk.\n" +
    
  •   		"Controls:  ↑/k, ↓/j  Navigate  |  space/→  Next value  |  ←  Prev value  |  c/Esc  Back  |  q  Quit",
    
  •   ))
    
  •   helpText := "In-memory settings updated; fix the error above to persist to disk.\n" +
    
  •   	"Controls:  ↑/k, ↓/j  Navigate  |  space/→  Next value  |  ←  Prev value  |  c/Esc  Back  |  q  Quit"
    
  •   s.WriteString(helpStyle.MaxWidth(cw).Render(helpText))
    
    } else {
    cfgPathStr := m.cfgPath
    if cfgPathStr == "" {
    @@ -313,13 +321,13 @@
    } else {
    cfgPathStr = AbbreviateUserHome(cfgPathStr)
    }
  •   s.WriteString(helpStyle.Render(
    
  •   	"Changes saved immediately to " + cfgPathStr + "\n" +
    
  •   		"Controls:  ↑/k, ↓/j  Navigate  |  space/→  Next value  |  ←  Prev value  |  c/Esc  Back  |  q  Quit",
    
  •   ))
    
  •   helpText := "Changes saved immediately to " + cfgPathStr + "\n" +
    
  •   	"Controls:  ↑/k, ↓/j  Navigate  |  space/→  Next value  |  ←  Prev value  |  c/Esc  Back  |  q  Quit"
    
  •   s.WriteString(helpStyle.MaxWidth(cw).Render(helpText))
    
    }
  • return boxStyle.Render(s.String())
  • innerW := PanelBlockWidth(m.windowWidth)
  • return boxStyle.Width(innerW).Render(s.String())
    }

func (m RepoSelectorModel) syncRuntimeFromConfig(cmds []tea.Cmd) (RepoSelectorModel, []tea.Cmd) {

diff --git a/internal/ui/panel_layout.go b/internal/ui/panel_layout.go
new file mode 100644
--- /dev/null
+++ b/internal/ui/panel_layout.go
@@ -1,0 +1,41 @@
+package ui
+
+// Horizontal layout for the main Bubble Tea panel (must stay consistent across
+// repo list, ignored list, settings, rain banner, and PathWidthFor).
+
+// panelOuterMarginTotal is the number of terminal columns reserved outside the
+// bordered panel so the frame does not touch the left/right edge.
+const panelOuterMarginTotal = 6
+
+// panelBoxHorizontalPadding is the sum of left and right padding on boxStyle
+// (Padding(1, 2) => 2 + 2). Keep in sync with boxStyle in repo_selector.go and
+// applyColorProfile in color_profiles.go.
+const panelBoxHorizontalPadding = 4
+
+// PanelBlockWidth returns the lipgloss Width passed to boxStyle for the main panel.
+func PanelBlockWidth(terminalWidth int) int {

  • w := terminalWidth - panelOuterMarginTotal
  • if w < 0 {
  •   return 0
    
  • }
  • return w
    +}

+// PanelTextWidth is the maximum cell width for one line of content inside the
+// panel after horizontal padding (use for clamping, PathWidthFor, MaxWidth).
+func PanelTextWidth(terminalWidth int) int {

  • w := PanelBlockWidth(terminalWidth) - panelBoxHorizontalPadding
  • if w < 0 {
  •   return 0
    
  • }
  • return w
    +}

+// RainDisplayWidth caps the ASCII rain banner width to match the text area.
+func RainDisplayWidth(terminalWidth int) int {

  • tw := PanelTextWidth(terminalWidth)
  • if tw > 70 {
  •   return 70
    
  • }
  • return tw
    +}

diff --git a/internal/ui/panel_layout_test.go b/internal/ui/panel_layout_test.go
new file mode 100644
--- /dev/null
+++ b/internal/ui/panel_layout_test.go
@@ -1,0 +1,37 @@
+package ui
+
+import (

  • "testing"
  • "github.com/charmbracelet/lipgloss"
    +)

+// TestPanelLayoutMatchesBoxStylePadding keeps panelBoxHorizontalPadding in sync
+// with the main panel lipgloss style (Border + Padding(1,2)).
+func TestPanelLayoutMatchesBoxStylePadding(t *testing.T) {

  • box := lipgloss.NewStyle().
  •   Border(lipgloss.RoundedBorder()).
    
  •   Padding(1, 2)
    
  • if got := box.GetHorizontalPadding(); got != panelBoxHorizontalPadding {
  •   t.Fatalf("lipgloss horizontal padding = %d, panelBoxHorizontalPadding = %d — update one to match",
    
  •   	got, panelBoxHorizontalPadding)
    
  • }
    +}

+func TestPanelBlockAndTextWidth(t *testing.T) {

  • if got := PanelBlockWidth(120); got != 114 {
  •   t.Fatalf("PanelBlockWidth(120) = %d, want 114", got)
    
  • }
  • if got := PanelTextWidth(120); got != 110 {
  •   t.Fatalf("PanelTextWidth(120) = %d, want 110", got)
    

... diff truncated: showing 800 of 1230 lines


</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-6504a74e-b09f-48eb-9de8-70d4c3ec94e6">here</a>.</sub>

</details>

Comment thread internal/ui/config_view.go
cursoragent and others added 6 commits April 17, 2026 05:37
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
RainDisplayWidth was capped at 70 while panel content used full
PanelTextWidth, so the first rows were shorter than body lines and
lipgloss rounded borders showed gaps on main menu and settings.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Lipgloss measures line width with ansi.StringWidth while many desktop
terminals render emoji as width-2, so top/bottom border segments could be
shorter than content (gaps at corners). Pre-render inner block with
lipgloss Width(innerBlock - horizontalPadding) then apply boxStyle.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
repoListVisibleCount used a fixed overhead (~5 lines for help) while
helpStyle.MaxWidth and the bordered scan status wrap to many more rows,
so the bordered panel exceeded window height and the top border scrolled away.

Binary-search the largest list capacity whose lipgloss.Height(renderMainPanelBox)
fits windowHeight; same for ignored view.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
clampCellWidth uses Inline(true), which collapsed the multiline scan
box into one broken line. Render the scan box then wrap in Width(cw)
Align(Left) for a full-width left-aligned scanbar.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
…ait)

- Remove unused min() tripping golangci-lint unused
- README: clarify --branch-mode applies on full hydration path, not only --sync
- PathWidthFor: rename/document path row reserve constant
- analyzeRepository: use ctx.Err() after git errors instead of errors.Is Canceled
- config: writeAtomicReplacing removes temp file on WriteFile failure; dedupe Save/WriteExample
- runRainTUIStream: wait for registry bridge goroutine after scan (saveRegistry)

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/ui/view_layout_test.go (2)

27-27: Nit: cap shadows the built-in.

Using cap as a local variable name is legal but hides Go’s built-in cap(). capacity (or best) reads a little cleaner and matches the naming in view_layout.go.

Also applies to: 56-56

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/view_layout_test.go` at line 27, Rename the local variable named
cap (which shadows Go's built-in cap()) to a clearer name like capacity or best
in the test function(s) that call mainViewMeasuredRepoListCapacity() (the
occurrences around the calls at the current locations and the other instance
noted around line 56); update all usages in those scopes to use the new name to
avoid hiding the built-in and to match the naming used in view_layout.go.

10-70: Consider a table-driven form for the two capacity tests.

Both tests assert the same invariant (panelOuterHeight(cap) <= windowHeight and maximality at cap+1) against two different view modes. Factoring the fits+maximality check into a helper and driving it from a small table would share the assertion logic and make future views trivial to add. Keeping as-is is fine for now.

As per coding guidelines: "Prefer table-driven tests for multi-case functions".

♻️ Sketch
type capCase struct {
    name       string
    windowW    int
    windowH    int
    listLen    int
    measure    func(m RepoSelectorModel) int
    outerH     func(m RepoSelectorModel, cap int) int
    buildModel func(n int) RepoSelectorModel
}
// iterate cases, run the two assertions once
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/view_layout_test.go` around lines 10 - 70, Refactor the two tests
TestMainViewMeasuredRepoListCapacityFitsWindow and
TestIgnoredMeasuredListCapacityFitsWindow into a table-driven test that iterates
cases; for each case build the RepoSelectorModel (use buildModel), call the
appropriate measure function (mainViewMeasuredRepoListCapacity or
ignoredMeasuredListCapacity) and assert the same invariants using a shared
helper: check capacity >= 1, verify outer height (mainViewPanelOuterHeight or
ignoredViewPanelOuterHeight) for cap fits within windowHeight, and verify cap+1
does not fit; reference the functions mainViewMeasuredRepoListCapacity,
ignoredMeasuredListCapacity, mainViewPanelOuterHeight and
ignoredViewPanelOuterHeight when wiring the case funcs so adding new view modes
only requires adding another table entry.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/ui/repo_selector.go`:
- Around line 715-721: The guard in clampCellWidth currently returns the
original string when maxCells == 1, preventing a 1-cell clamp; change the
early-return condition from "maxCells <= 1" to "maxCells <= 0" (or "maxCells <
1") so that a request to clamp to one cell actually applies lipgloss truncation,
and add a brief comment above clampCellWidth explaining the degeneracy rule
(only non-positive maxCells bypass clamping) so intent is clear; keep the use of
lipgloss.NewStyle().MaxWidth(maxCells).Inline(true).Render(s) unchanged.

---

Nitpick comments:
In `@internal/ui/view_layout_test.go`:
- Line 27: Rename the local variable named cap (which shadows Go's built-in
cap()) to a clearer name like capacity or best in the test function(s) that call
mainViewMeasuredRepoListCapacity() (the occurrences around the calls at the
current locations and the other instance noted around line 56); update all
usages in those scopes to use the new name to avoid hiding the built-in and to
match the naming used in view_layout.go.
- Around line 10-70: Refactor the two tests
TestMainViewMeasuredRepoListCapacityFitsWindow and
TestIgnoredMeasuredListCapacityFitsWindow into a table-driven test that iterates
cases; for each case build the RepoSelectorModel (use buildModel), call the
appropriate measure function (mainViewMeasuredRepoListCapacity or
ignoredMeasuredListCapacity) and assert the same invariants using a shared
helper: check capacity >= 1, verify outer height (mainViewPanelOuterHeight or
ignoredViewPanelOuterHeight) for cap fits within windowHeight, and verify cap+1
does not fit; reference the functions mainViewMeasuredRepoListCapacity,
ignoredMeasuredListCapacity, mainViewPanelOuterHeight and
ignoredViewPanelOuterHeight when wiring the case funcs so adding new view modes
only requires adding another table entry.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 77b80dd0-89ef-4608-adc2-604a915cd797

📥 Commits

Reviewing files that changed from the base of the PR and between cf8e229 and 99c2f09.

📒 Files selected for processing (11)
  • README.md
  • cmd/root.go
  • internal/config/loader.go
  • internal/git/scanner.go
  • internal/ui/config_view.go
  • internal/ui/panel_layout.go
  • internal/ui/panel_layout_test.go
  • internal/ui/path_display.go
  • internal/ui/repo_selector.go
  • internal/ui/view_layout.go
  • internal/ui/view_layout_test.go
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/config/loader.go
  • internal/ui/panel_layout_test.go
  • internal/git/scanner.go
  • internal/ui/config_view.go
  • internal/ui/panel_layout.go
  • internal/ui/path_display.go

Comment thread internal/ui/repo_selector.go
cursoragent and others added 2 commits April 17, 2026 06:35
…s gap)

- README: --dry-run + --no-scan caveat in flags table; dry-run example comment
- README: document config.toml.lock + bounded TryLock + atomic write (was wrong)
- config: TryLockContext with timeout for SaveConfig/WriteExampleConfig (TUI)
- settings: label 'key: ' + comment so value never abuts colon (Bugbot)

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
…ls==1

- clampCellWidth: maxCells < 1 bypass only; document degeneracy (CodeRabbit)
- view_layout_test: table-driven cases, helper, rename cap -> capacity

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/ui/repo_selector.go (1)

864-878: Minor: folder truncation slices by bytes, not runes.

folder[len(folder)-maxLen+3:] is a byte slice; for paths containing multibyte UTF-8 (e.g., non-ASCII user/repo names) this can split a rune and produce a leading replacement character. Pre-existing behavior, but since the surrounding maxLen logic is being updated it's worth tightening while you're here.

♻️ Proposed rune-safe truncation
-		if len(folder) > maxLen {
-			folder = "..." + folder[len(folder)-maxLen+3:]
-		}
+		if runes := []rune(folder); len(runes) > maxLen {
+			folder = "..." + string(runes[len(runes)-maxLen+3:])
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/repo_selector.go` around lines 864 - 878, The truncation logic in
the default branch uses byte slicing (folder[len(folder)-maxLen+3:]) which can
cut multibyte UTF‑8 runes; change to rune-safe slicing by converting folder to
runes (e.g., r := []rune(folder)), compute the rune length and take the last N
runes (N = maxLen-3) before prefixing "..." so the variable folder is rebuilt
from runes rather than bytes; update the code that sets folder and the condition
that checks len(folder) accordingly (refer to the local variables folder, maxLen
and the default case).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/ui/repo_selector.go`:
- Around line 864-878: The truncation logic in the default branch uses byte
slicing (folder[len(folder)-maxLen+3:]) which can cut multibyte UTF‑8 runes;
change to rune-safe slicing by converting folder to runes (e.g., r :=
[]rune(folder)), compute the rune length and take the last N runes (N =
maxLen-3) before prefixing "..." so the variable folder is rebuilt from runes
rather than bytes; update the code that sets folder and the condition that
checks len(folder) accordingly (refer to the local variables folder, maxLen and
the default case).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad33cb40-10f4-4dc2-a4ca-e3db8699152b

📥 Commits

Reviewing files that changed from the base of the PR and between 99c2f09 and 399a601.

📒 Files selected for processing (5)
  • README.md
  • internal/config/loader.go
  • internal/ui/config_view.go
  • internal/ui/repo_selector.go
  • internal/ui/view_layout_test.go
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/ui/view_layout_test.go
  • internal/config/loader.go
  • internal/ui/config_view.go

Comment thread internal/ui/view_layout.go
…arch

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Old default_mode values cause config load failure
    • Validate now maps legacy default_mode values with the push- prefix to sync-default before the allowed-value switch, matching prior ParseMode behavior so old configs load.

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 2be0552. Configure here.

Comment thread internal/config/loader.go
@bschellenberger2600 bschellenberger2600 merged commit 3bec360 into main Apr 17, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants